|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +from pathlib import Path |
| 6 | +from types import SimpleNamespace |
5 | 7 | from typing import TYPE_CHECKING |
| 8 | +from unittest.mock import AsyncMock |
6 | 9 | import logging |
7 | 10 |
|
8 | 11 | import pytest |
9 | 12 | from pytest_homeassistant_custom_component.common import MockConfigEntry |
10 | 13 |
|
11 | 14 | from homeassistant.config_entries import ConfigEntryState |
12 | | -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED |
| 15 | +from homeassistant.const import ( |
| 16 | + EVENT_HOMEASSISTANT_START, |
| 17 | + EVENT_HOMEASSISTANT_STARTED, |
| 18 | + RESTART_EXIT_CODE, |
| 19 | +) |
| 20 | +from homeassistant.core import CoreState |
| 21 | +from homeassistant.helpers import issue_registry as ir |
13 | 22 |
|
14 | 23 | from custom_components import spook |
15 | 24 | from custom_components.spook.const import DOMAIN |
| 25 | +from custom_components.spook.integration_linking import ( |
| 26 | + link_sub_integrations, |
| 27 | + unlink_sub_integrations, |
| 28 | +) |
16 | 29 |
|
17 | 30 | if TYPE_CHECKING: |
| 31 | + from collections.abc import Callable |
| 32 | + |
18 | 33 | from homeassistant.config_entries import ConfigEntry |
19 | 34 | from homeassistant.core import HomeAssistant |
20 | 35 |
|
@@ -50,6 +65,30 @@ async def async_on_unload(self) -> None: |
50 | 65 | """Unload no repairs.""" |
51 | 66 |
|
52 | 67 |
|
| 68 | +def _sub_integration_names() -> set[str]: |
| 69 | + """Return bundled Spook sub-integration names.""" |
| 70 | + return { |
| 71 | + manifest.parent.name |
| 72 | + for manifest in ( |
| 73 | + Path(__file__).parents[1] / "custom_components" / DOMAIN / "integrations" |
| 74 | + ).rglob("*/manifest.json") |
| 75 | + } |
| 76 | + |
| 77 | + |
| 78 | +def _create_sub_integration_sources(config_dir: Path) -> None: |
| 79 | + """Create matching config-dir source folders for Spook sub-integrations.""" |
| 80 | + for name in _sub_integration_names(): |
| 81 | + (config_dir / "custom_components" / DOMAIN / "integrations" / name).mkdir( |
| 82 | + parents=True, |
| 83 | + exist_ok=True, |
| 84 | + ) |
| 85 | + |
| 86 | + |
| 87 | +def _link_sub_integrations_changed(_hass: HomeAssistant) -> bool: |
| 88 | + """Pretend sub-integration symlink creation changed the config dir.""" |
| 89 | + return True |
| 90 | + |
| 91 | + |
53 | 92 | def _link_sub_integrations_noop(_hass: HomeAssistant) -> bool: |
54 | 93 | """Skip sub-integration symlink creation during lifecycle tests.""" |
55 | 94 | return False |
@@ -120,3 +159,140 @@ async def async_forward_no_platforms( |
120 | 159 | await hass.async_block_till_done() |
121 | 160 |
|
122 | 161 | assert "Unable to remove unknown job listener" not in caplog.text |
| 162 | + |
| 163 | + |
| 164 | +def test_link_sub_integrations_creates_links_idempotently_and_unlinks( |
| 165 | + tmp_path: Path, |
| 166 | +) -> None: |
| 167 | + """Test sub-integration links are created, skipped, and removed.""" |
| 168 | + fake_hass = SimpleNamespace(config=SimpleNamespace(config_dir=tmp_path)) |
| 169 | + _create_sub_integration_sources(tmp_path) |
| 170 | + |
| 171 | + assert link_sub_integrations(fake_hass) is True |
| 172 | + |
| 173 | + for name in _sub_integration_names(): |
| 174 | + link = tmp_path / "custom_components" / name |
| 175 | + assert link.is_symlink() |
| 176 | + assert link.readlink() == ( |
| 177 | + tmp_path / "custom_components" / DOMAIN / "integrations" / name |
| 178 | + ) |
| 179 | + |
| 180 | + assert link_sub_integrations(fake_hass) is False |
| 181 | + |
| 182 | + unlink_sub_integrations(fake_hass) |
| 183 | + |
| 184 | + for name in _sub_integration_names(): |
| 185 | + assert not (tmp_path / "custom_components" / name).exists() |
| 186 | + |
| 187 | + |
| 188 | +async def test_remove_entry_unlinks_sub_integrations(tmp_path: Path) -> None: |
| 189 | + """Test removing Spook unlinks the sub-integrations.""" |
| 190 | + fake_hass = SimpleNamespace( |
| 191 | + config=SimpleNamespace(config_dir=tmp_path), |
| 192 | + async_add_executor_job=AsyncMock(side_effect=lambda func, *args: func(*args)), |
| 193 | + ) |
| 194 | + _create_sub_integration_sources(tmp_path) |
| 195 | + assert link_sub_integrations(fake_hass) is True |
| 196 | + |
| 197 | + await spook.async_remove_entry(fake_hass, MockConfigEntry(domain=DOMAIN, data={})) |
| 198 | + |
| 199 | + for name in _sub_integration_names(): |
| 200 | + assert not (tmp_path / "custom_components" / name).exists() |
| 201 | + |
| 202 | + |
| 203 | +@pytest.mark.parametrize( |
| 204 | + ("state", "restart_choice"), |
| 205 | + [ |
| 206 | + (CoreState.not_running, "later"), |
| 207 | + (CoreState.starting, "later"), |
| 208 | + (CoreState.running, "later"), |
| 209 | + (CoreState.not_running, "now"), |
| 210 | + ], |
| 211 | +) |
| 212 | +async def test_setup_entry_restart_required_paths( |
| 213 | + hass: HomeAssistant, |
| 214 | + monkeypatch: pytest.MonkeyPatch, |
| 215 | + state: CoreState, |
| 216 | + restart_choice: str, |
| 217 | +) -> None: |
| 218 | + """Test setup behavior when sub-integration linking requires a restart.""" |
| 219 | + restart_now = restart_choice == "now" |
| 220 | + original_async_stop = hass.async_stop |
| 221 | + |
| 222 | + async def async_cleanup_hass() -> None: |
| 223 | + """Stop Home Assistant after the restart request is asserted.""" |
| 224 | + monkeypatch.setattr(hass, "state", CoreState.running) |
| 225 | + await original_async_stop() |
| 226 | + |
| 227 | + async_stop = AsyncMock() |
| 228 | + |
| 229 | + async def async_forward_no_platforms( |
| 230 | + _hass: HomeAssistant, |
| 231 | + _entry: ConfigEntry, |
| 232 | + ) -> None: |
| 233 | + """Forward no ectoplasm setup during restart tests.""" |
| 234 | + |
| 235 | + async def async_forward_entry_setups_noop( |
| 236 | + _entry: ConfigEntry, |
| 237 | + _platforms: list[str], |
| 238 | + ) -> None: |
| 239 | + """Forward no platform setup during restart tests.""" |
| 240 | + |
| 241 | + def setup_cache_invalidation_noop(_hass: HomeAssistant) -> Callable[[], None]: |
| 242 | + """Skip entity ID cache invalidation listeners during restart tests.""" |
| 243 | + return lambda: None |
| 244 | + |
| 245 | + monkeypatch.setattr(spook, "PLATFORMS", []) |
| 246 | + monkeypatch.setattr(spook, "async_forward_setup_entry", async_forward_no_platforms) |
| 247 | + monkeypatch.setattr( |
| 248 | + hass.config_entries, |
| 249 | + "async_forward_entry_setups", |
| 250 | + async_forward_entry_setups_noop, |
| 251 | + ) |
| 252 | + monkeypatch.setattr(spook, "SpookServiceManager", _NoopSpookServiceManager) |
| 253 | + monkeypatch.setattr(spook, "SpookRepairManager", _NoopSpookRepairManager) |
| 254 | + monkeypatch.setattr(spook, "link_sub_integrations", _link_sub_integrations_changed) |
| 255 | + monkeypatch.setattr( |
| 256 | + spook, |
| 257 | + "async_setup_all_entity_ids_cache_invalidation", |
| 258 | + setup_cache_invalidation_noop, |
| 259 | + ) |
| 260 | + monkeypatch.setattr(hass, "async_stop", async_stop) |
| 261 | + monkeypatch.setattr(hass, "state", state) |
| 262 | + if restart_now: |
| 263 | + hass.data[DOMAIN] = "Boo!" |
| 264 | + |
| 265 | + entry = MockConfigEntry(domain=DOMAIN, title="Your homie", data={}) |
| 266 | + entry.add_to_hass(hass) |
| 267 | + |
| 268 | + result = await spook.async_setup_entry(hass, entry) |
| 269 | + |
| 270 | + if state == CoreState.running and not restart_now: |
| 271 | + assert result is True |
| 272 | + else: |
| 273 | + assert result is False |
| 274 | + |
| 275 | + if restart_now or state == CoreState.starting: |
| 276 | + await hass.async_block_till_done() |
| 277 | + hass.async_stop.assert_awaited_once_with(RESTART_EXIT_CODE) |
| 278 | + assert ir.async_get(hass).async_get_issue(DOMAIN, "restart_required") is None |
| 279 | + await async_cleanup_hass() |
| 280 | + return |
| 281 | + |
| 282 | + if state == CoreState.not_running: |
| 283 | + hass.async_stop.assert_not_called() |
| 284 | + |
| 285 | + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) |
| 286 | + await hass.async_block_till_done() |
| 287 | + |
| 288 | + hass.async_stop.assert_awaited_once_with(RESTART_EXIT_CODE) |
| 289 | + assert ir.async_get(hass).async_get_issue(DOMAIN, "restart_required") is None |
| 290 | + await async_cleanup_hass() |
| 291 | + return |
| 292 | + |
| 293 | + hass.async_stop.assert_not_called() |
| 294 | + issue = ir.async_get(hass).async_get_issue(DOMAIN, "restart_required") |
| 295 | + assert issue is not None |
| 296 | + assert issue.severity is ir.IssueSeverity.WARNING |
| 297 | + assert issue.translation_key == "restart_required" |
| 298 | + await original_async_stop() |
0 commit comments