Skip to content

Commit d949186

Browse files
authored
Cover Spook setup restart paths (#1292)
1 parent 6ad5240 commit d949186

1 file changed

Lines changed: 177 additions & 1 deletion

File tree

tests/test_setup.py

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,34 @@
22

33
from __future__ import annotations
44

5+
from pathlib import Path
6+
from types import SimpleNamespace
57
from typing import TYPE_CHECKING
8+
from unittest.mock import AsyncMock
69
import logging
710

811
import pytest
912
from pytest_homeassistant_custom_component.common import MockConfigEntry
1013

1114
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
1322

1423
from custom_components import spook
1524
from custom_components.spook.const import DOMAIN
25+
from custom_components.spook.integration_linking import (
26+
link_sub_integrations,
27+
unlink_sub_integrations,
28+
)
1629

1730
if TYPE_CHECKING:
31+
from collections.abc import Callable
32+
1833
from homeassistant.config_entries import ConfigEntry
1934
from homeassistant.core import HomeAssistant
2035

@@ -50,6 +65,30 @@ async def async_on_unload(self) -> None:
5065
"""Unload no repairs."""
5166

5267

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+
5392
def _link_sub_integrations_noop(_hass: HomeAssistant) -> bool:
5493
"""Skip sub-integration symlink creation during lifecycle tests."""
5594
return False
@@ -120,3 +159,140 @@ async def async_forward_no_platforms(
120159
await hass.async_block_till_done()
121160

122161
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

Comments
 (0)