Skip to content

Commit caa2942

Browse files
authored
Merge pull request LF2b2w#51 from geofffranks/main
fix: stop entry from getting stuck in failed_unload after OAuth refresh
2 parents 304791a + 4c51f9e commit caa2942

4 files changed

Lines changed: 258 additions & 20 deletions

File tree

custom_components/u_tec/__init__.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
128128
"coordinator": coordinator,
129129
"auth_data": auth_data,
130130
"webhook_handler": webhook_handler,
131+
# Track previous push_enabled so async_update_options can detect a real
132+
# change. entry.options reflects current state; without a stored prior
133+
# we can't tell a toggle from a no-op data update (e.g. OAuth refresh).
134+
"push_enabled": push_enabled,
131135
}
132136

133137
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
134-
# Unload the entry if the user disables push notifications
138+
# Listener fires on ANY entry update (data or options). The handler is
139+
# responsible for filtering down to actual option changes — see
140+
# async_update_options. Calling async_reload from this listener on every
141+
# update would loop on OAuth token refreshes, which the integration cannot
142+
# recover from without a working async_unload_entry.
135143
entry.async_on_unload(entry.add_update_listener(async_update_options))
136144
# Unregister the webhook when the entry is unloaded
137145
entry.async_on_unload(webhook_handler.unregister_webhook)
@@ -141,29 +149,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
141149
return True
142150

143151

152+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
153+
"""Unload a config entry."""
154+
unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
155+
if unload_ok:
156+
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
157+
return unload_ok
158+
159+
144160
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
145-
"""Handle options update."""
146-
# Get the webhook handler and coordinator
147-
webhook_handler = hass.data[DOMAIN][entry.entry_id]["webhook_handler"]
148-
coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
149-
auth_data = hass.data[DOMAIN][entry.entry_id]["auth_data"]
150-
151-
# Check if push notification setting has changed
152-
old_push_enabled = entry.data.get("options", {}).get(CONF_PUSH_ENABLED, True)
161+
"""Handle options update.
162+
163+
Reconciles webhook registration and push_devices inline based on the new
164+
options. Deliberately does NOT call async_reload — token refreshes update
165+
entry.data and would otherwise trigger a reload on every refresh.
166+
"""
167+
entry_data = hass.data[DOMAIN][entry.entry_id]
168+
webhook_handler = entry_data["webhook_handler"]
169+
coordinator = entry_data["coordinator"]
170+
auth_data = entry_data["auth_data"]
171+
172+
old_push_enabled = entry_data.get("push_enabled", True)
153173
new_push_enabled = entry.options.get(CONF_PUSH_ENABLED, True)
154174

155-
# Update push devices in coordinator
156175
coordinator.push_devices = entry.options.get(CONF_PUSH_DEVICES, [])
157176

158-
# Handle webhook registration/unregistration if needed
159177
if old_push_enabled != new_push_enabled:
160178
if new_push_enabled:
161-
# Register webhook
162179
await webhook_handler.async_register_webhook(auth_data)
163180
else:
164-
# Unregister webhook
165-
webhook_handler.unregister_webhook()
166-
await hass.config_entries.async_reload(entry.entry_id)
181+
await webhook_handler.unregister_webhook()
182+
entry_data["push_enabled"] = new_push_enabled
167183

168184

169185
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:

custom_components/u_tec/api.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,15 +119,20 @@ async def async_register_webhook(self, auth_data) -> bool:
119119
_LOGGER.error("Failed to register webhook with U-Tec API: %s", err)
120120
return False
121121

122-
# Register HA-side webhook handler (only once)
122+
# Register HA-side webhook handler (only once). webhook.async_register
123+
# returns None, so we use _unregister_webhook as a boolean flag — True
124+
# once registered. Was previously assigned the return value, which left
125+
# it None and broke this guard on every re-entry (notably the 24h
126+
# re-register timer), raising "Handler is already defined!".
123127
if not self._unregister_webhook:
124-
self._unregister_webhook = webhook.async_register(
128+
webhook.async_register(
125129
self.hass,
126130
DOMAIN,
127131
WEBHOOK_HANDLER,
128132
self.webhook_id,
129133
self._handle_webhook,
130134
)
135+
self._unregister_webhook = True
131136

132137
self.webhook_url = webhook_url
133138

tests/test_setup.py

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
import pytest
88

9-
from custom_components.u_tec import async_setup_entry, async_update_options
9+
from custom_components.u_tec import (
10+
async_setup_entry,
11+
async_unload_entry,
12+
async_update_options,
13+
)
1014
from custom_components.u_tec.const import CONF_PUSH_ENABLED, DOMAIN
1115
from tests.common import make_config_entry
1216

@@ -119,6 +123,145 @@ async def _reload_side_effect(_entry_id):
119123
await async_update_options(hass, entry)
120124
await hass.async_block_till_done()
121125

122-
# Exactly two registrations: initial setup (push=False so not called) + reload (push=True).
123-
# The initial setup is push=False, so only the reload should have registered.
126+
# Initial setup is push=False (no register). Flipping to push=True via the
127+
# options listener registers the webhook inline — no reload required.
124128
assert mock_register.await_count == 1
129+
130+
131+
async def test_async_update_options_toggles_webhook_off(hass, patched_uhomeapi):
132+
"""Disabling push while integration is running unregisters the webhook."""
133+
entry = make_config_entry(options={CONF_PUSH_ENABLED: True})
134+
entry.add_to_hass(hass)
135+
136+
with _patched_setup_env(hass), patch(
137+
"custom_components.u_tec.api.AsyncPushUpdateHandler.async_register_webhook",
138+
new=AsyncMock(return_value=True),
139+
), patch(
140+
"custom_components.u_tec.api.AsyncPushUpdateHandler.unregister_webhook",
141+
new=AsyncMock(return_value=None),
142+
) as mock_unregister, patch.object(
143+
hass.config_entries, "async_reload", new=AsyncMock(),
144+
):
145+
await async_setup_entry(hass, entry)
146+
147+
object.__setattr__(entry, "options", MappingProxyType({CONF_PUSH_ENABLED: False}))
148+
await async_update_options(hass, entry)
149+
150+
mock_unregister.assert_awaited_once()
151+
152+
153+
async def test_async_update_options_re_enable_after_disable_registers(hass, patched_uhomeapi):
154+
"""Push True → False → True actually re-registers (regression for entry.data lookup bug)."""
155+
entry = make_config_entry(options={CONF_PUSH_ENABLED: True})
156+
entry.add_to_hass(hass)
157+
158+
with _patched_setup_env(hass), patch(
159+
"custom_components.u_tec.api.AsyncPushUpdateHandler.async_register_webhook",
160+
new=AsyncMock(return_value=True),
161+
) as mock_register, patch(
162+
"custom_components.u_tec.api.AsyncPushUpdateHandler.unregister_webhook",
163+
new=AsyncMock(return_value=None),
164+
), patch.object(
165+
hass.config_entries, "async_reload", new=AsyncMock(),
166+
):
167+
await async_setup_entry(hass, entry)
168+
# setup with push=True → 1 register call
169+
assert mock_register.await_count == 1
170+
171+
object.__setattr__(entry, "options", MappingProxyType({CONF_PUSH_ENABLED: False}))
172+
await async_update_options(hass, entry)
173+
# off — still 1 register call
174+
assert mock_register.await_count == 1
175+
176+
object.__setattr__(entry, "options", MappingProxyType({CONF_PUSH_ENABLED: True}))
177+
await async_update_options(hass, entry)
178+
# back on — should re-register
179+
assert mock_register.await_count == 2
180+
181+
182+
async def test_async_update_options_does_not_reload(hass, patched_uhomeapi):
183+
"""OAuth token refresh path: listener fires with options unchanged. Must not call async_reload."""
184+
entry = make_config_entry(options={CONF_PUSH_ENABLED: False})
185+
entry.add_to_hass(hass)
186+
187+
with _patched_setup_env(hass), patch.object(
188+
hass.config_entries, "async_reload", new=AsyncMock(),
189+
) as mock_reload:
190+
await async_setup_entry(hass, entry)
191+
# Listener fires after async_update_entry — options identical to setup.
192+
await async_update_options(hass, entry)
193+
194+
mock_reload.assert_not_called()
195+
196+
197+
async def test_unload_entry_returns_true_and_clears_hass_data(hass, patched_uhomeapi):
198+
"""async_unload_entry must exist and clean up hass.data[DOMAIN][entry_id]."""
199+
entry = make_config_entry(options={CONF_PUSH_ENABLED: False})
200+
entry.add_to_hass(hass)
201+
202+
with _patched_setup_env(hass), patch.object(
203+
hass.config_entries,
204+
"async_unload_platforms",
205+
new=AsyncMock(return_value=True),
206+
) as mock_unload_platforms:
207+
await async_setup_entry(hass, entry)
208+
assert entry.entry_id in hass.data[DOMAIN]
209+
210+
result = await async_unload_entry(hass, entry)
211+
212+
assert result is True
213+
mock_unload_platforms.assert_awaited_once()
214+
assert entry.entry_id not in hass.data.get(DOMAIN, {})
215+
216+
217+
async def test_unload_entry_leaves_data_when_platform_unload_fails(
218+
hass, patched_uhomeapi,
219+
):
220+
"""If platforms fail to unload, hass.data must stay intact for next attempt."""
221+
entry = make_config_entry(options={CONF_PUSH_ENABLED: False})
222+
entry.add_to_hass(hass)
223+
224+
with _patched_setup_env(hass), patch.object(
225+
hass.config_entries,
226+
"async_unload_platforms",
227+
new=AsyncMock(return_value=False),
228+
):
229+
await async_setup_entry(hass, entry)
230+
result = await async_unload_entry(hass, entry)
231+
232+
assert result is False
233+
assert entry.entry_id in hass.data[DOMAIN]
234+
235+
236+
async def test_oauth_token_refresh_does_not_unload_entry(hass, patched_uhomeapi):
237+
"""End-to-end: real async_update_entry → real update listener → no reload.
238+
239+
Exercises HA's full update-listener wiring rather than calling
240+
async_update_options directly. This is the exact path an OAuth refresh
241+
takes: OAuth2Session.async_ensure_token_valid → async_update_entry(data=...)
242+
→ fires entry.update_listeners → async_update_options. Before the fix, the
243+
listener called async_reload, which silently transitioned the entry to
244+
FAILED_UNLOAD because async_unload_entry was missing.
245+
"""
246+
entry = make_config_entry(options={CONF_PUSH_ENABLED: False})
247+
entry.add_to_hass(hass)
248+
249+
with _patched_setup_env(hass), patch.object(
250+
hass.config_entries, "async_reload", new=AsyncMock(),
251+
) as mock_reload:
252+
await async_setup_entry(hass, entry)
253+
await hass.async_block_till_done()
254+
255+
# Simulate OAuth refresh writing a new token to entry.data — same call
256+
# OAuth2Session.async_ensure_token_valid makes after a token refresh.
257+
new_token = {**entry.data["token"], "access_token": "refreshed-token"}
258+
hass.config_entries.async_update_entry(
259+
entry, data={**entry.data, "token": new_token},
260+
)
261+
await hass.async_block_till_done()
262+
263+
mock_reload.assert_not_called()
264+
# async_reload would have triggered async_unload_platforms; verify the
265+
# entry's coordinator + webhook handler are still the same instances.
266+
assert entry.entry_id in hass.data[DOMAIN]
267+
assert hass.data[DOMAIN][entry.entry_id]["push_enabled"] is False

tests/test_webhook_registration.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""Tests for AsyncPushUpdateHandler.async_register_webhook — URL resolution."""
22

3+
from datetime import timedelta
34
from unittest.mock import MagicMock, patch
45

56
from homeassistant.helpers.network import NoURLAvailableError
7+
from homeassistant.util import dt as dt_util
8+
from pytest_homeassistant_custom_component.common import async_fire_time_changed
69

710
from custom_components.u_tec.api import AsyncPushUpdateHandler
811

@@ -141,3 +144,74 @@ async def test_unregister_noop_when_nothing_registered(hass, mock_uhome_api):
141144
h = AsyncPushUpdateHandler(hass, mock_uhome_api, entry_id="e1")
142145
# Neither _unregister_webhook nor _cancel_reregister is set — should not raise
143146
await h.unregister_webhook()
147+
148+
149+
async def test_register_twice_does_not_re_register_handler(hass, mock_uhome_api):
150+
"""webhook.async_register returns None — guard must still skip on second call.
151+
152+
Regression: storing the None return value as the "registered" flag broke the
153+
guard, so the next call (e.g. the 24h re-register timer) re-entered the
154+
block and webhook.async_register raised ValueError("Handler is already defined!").
155+
"""
156+
h = AsyncPushUpdateHandler(hass, mock_uhome_api, entry_id="e1")
157+
158+
with patch(
159+
"custom_components.u_tec.api.network.get_url",
160+
return_value="https://ha.example.com",
161+
), patch(
162+
"custom_components.u_tec.api.webhook.async_generate_url",
163+
return_value="https://ha.example.com/api/webhook/x",
164+
), patch(
165+
"custom_components.u_tec.api.webhook.async_register",
166+
return_value=None,
167+
) as mock_register, patch(
168+
"custom_components.u_tec.api.async_track_time_interval",
169+
return_value=MagicMock(),
170+
):
171+
await h.async_register_webhook(auth_data=MagicMock())
172+
await h.async_register_webhook(auth_data=MagicMock())
173+
174+
assert mock_register.call_count == 1
175+
176+
177+
async def test_24h_reregister_timer_does_not_raise(hass, mock_uhome_api):
178+
"""Advance time by 24h and verify the re-register timer fires cleanly.
179+
180+
Before the fix, the scheduled _async_reregister callback would call
181+
webhook.async_register again (because the guard's flag had been set to None),
182+
raising ValueError("Handler is already defined!"). With the boolean-flag
183+
fix, the guard correctly skips re-registration on the second cycle.
184+
"""
185+
h = AsyncPushUpdateHandler(hass, mock_uhome_api, entry_id="e1")
186+
187+
with patch(
188+
"custom_components.u_tec.api.network.get_url",
189+
return_value="https://ha.example.com",
190+
), patch(
191+
"custom_components.u_tec.api.webhook.async_generate_url",
192+
return_value="https://ha.example.com/api/webhook/x",
193+
), patch(
194+
"custom_components.u_tec.api.webhook.async_register",
195+
return_value=None,
196+
) as mock_register, patch(
197+
"custom_components.u_tec.api.webhook.async_unregister",
198+
):
199+
# Real async_track_time_interval scheduler — no mock — so the 24h timer
200+
# actually arms.
201+
await h.async_register_webhook(auth_data=MagicMock())
202+
203+
# 24h + 1 minute later, the daily re-register callback fires.
204+
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=24, minutes=1))
205+
await hass.async_block_till_done()
206+
207+
# Exactly one HA-side registration despite the timer cycling. If the
208+
# guard were broken, this would be 2 and the second call would raise.
209+
assert mock_register.call_count == 1
210+
# set_push_status fires for both the initial and the daily re-register —
211+
# confirms the timer callback actually ran (the test would otherwise
212+
# silently pass because the timer was never scheduled).
213+
assert mock_uhome_api.set_push_status.await_count == 2
214+
215+
# Clean up the (now re-armed) timer so the lingering-timer guard in
216+
# pytest-homeassistant-custom-component's teardown doesn't fail us.
217+
await h.unregister_webhook()

0 commit comments

Comments
 (0)