|
6 | 6 |
|
7 | 7 | import pytest |
8 | 8 |
|
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 | +) |
10 | 14 | from custom_components.u_tec.const import CONF_PUSH_ENABLED, DOMAIN |
11 | 15 | from tests.common import make_config_entry |
12 | 16 |
|
@@ -119,6 +123,145 @@ async def _reload_side_effect(_entry_id): |
119 | 123 | await async_update_options(hass, entry) |
120 | 124 | await hass.async_block_till_done() |
121 | 125 |
|
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. |
124 | 128 | 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 |
0 commit comments