diff --git a/homeassistant/components/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index 7c1d2f09b04b80e6172badc6ad40bc124a0f3857..3b302742ab6deb1c7aa72d7324e26572ad784cb0 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -7,9 +7,10 @@ from pathlib import Path from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.util import slugify -from .const import CONF_CALENDAR_NAME, DOMAIN +from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) @@ -24,9 +25,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Local Calendar from a config entry.""" hass.data.setdefault(DOMAIN, {}) - key = slugify(entry.data[CONF_CALENDAR_NAME]) - path = Path(hass.config.path(STORAGE_PATH.format(key=key))) - hass.data[DOMAIN][entry.entry_id] = LocalCalendarStore(hass, path) + if CONF_STORAGE_KEY not in entry.data: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_STORAGE_KEY: slugify(entry.data[CONF_CALENDAR_NAME]), + }, + ) + + path = Path(hass.config.path(STORAGE_PATH.format(key=entry.data[CONF_STORAGE_KEY]))) + store = LocalCalendarStore(hass, path) + try: + await store.async_load() + except OSError as err: + raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err + + hass.data[DOMAIN][entry.entry_id] = store await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index 2bde06820b63d94ee200005fe9edf37baa75c897..a5a75fee58b4259f4892aef3caa4d3551e70c2bc 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -7,8 +7,9 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.util import slugify -from .const import CONF_CALENDAR_NAME, DOMAIN +from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -31,6 +32,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) + key = slugify(user_input[CONF_CALENDAR_NAME]) + self._async_abort_entries_match({CONF_STORAGE_KEY: key}) + user_input[CONF_STORAGE_KEY] = key return self.async_create_entry( title=user_input[CONF_CALENDAR_NAME], data=user_input ) diff --git a/homeassistant/components/local_calendar/const.py b/homeassistant/components/local_calendar/const.py index 49cd5dc22a4f39b071990a9dd9269dacf48ddd75..1cfa774ab0ad7fbbe3fe8da5bf036981aaf7ae46 100644 --- a/homeassistant/components/local_calendar/const.py +++ b/homeassistant/components/local_calendar/const.py @@ -3,3 +3,4 @@ DOMAIN = "local_calendar" CONF_CALENDAR_NAME = "calendar_name" +CONF_STORAGE_KEY = "storage_key" diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py index 7dc294087bd183ad6c2961058a7a6958f50db242..8455fc2f34f6405369d26283cbcd2372c1d59d25 100644 --- a/tests/components/local_calendar/conftest.py +++ b/tests/components/local_calendar/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Generator from http import HTTPStatus from pathlib import Path from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch import urllib from aiohttp import ClientWebSocketResponse @@ -20,24 +20,31 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator CALENDAR_NAME = "Light Schedule" FRIENDLY_NAME = "Light schedule" +STORAGE_KEY = "light_schedule" TEST_ENTITY = "calendar.light_schedule" class FakeStore(LocalCalendarStore): """Mock storage implementation.""" - def __init__(self, hass: HomeAssistant, path: Path, ics_content: str) -> None: + def __init__( + self, hass: HomeAssistant, path: Path, ics_content: str, read_side_effect: Any + ) -> None: """Initialize FakeStore.""" super().__init__(hass, path) - self._content = ics_content + mock_path = self._mock_path = Mock() + mock_path.exists = self._mock_exists + mock_path.read_text = Mock() + mock_path.read_text.return_value = ics_content + mock_path.read_text.side_effect = read_side_effect + mock_path.write_text = self._mock_write_text + super().__init__(hass, mock_path) - def _load(self) -> str: - """Read from calendar storage.""" - return self._content + def _mock_exists(self) -> bool: + return self._mock_path.read_text.return_value is not None - def _store(self, ics_content: str) -> None: - """Persist the calendar storage.""" - self._content = ics_content + def _mock_write_text(self, content: str) -> None: + self._mock_path.read_text.return_value = content @pytest.fixture(name="ics_content", autouse=True) @@ -46,15 +53,23 @@ def mock_ics_content() -> str: return "" +@pytest.fixture(name="store_read_side_effect") +def mock_store_read_side_effect() -> Any | None: + """Fixture to raise errors from the FakeStore.""" + return None + + @pytest.fixture(name="store", autouse=True) -def mock_store(ics_content: str) -> Generator[None, None, None]: +def mock_store( + ics_content: str, store_read_side_effect: Any | None +) -> Generator[None, None, None]: """Test cleanup, remove any media storage persisted during the test.""" stores: dict[Path, FakeStore] = {} def new_store(hass: HomeAssistant, path: Path) -> FakeStore: if path not in stores: - stores[path] = FakeStore(hass, path, ics_content) + stores[path] = FakeStore(hass, path, ics_content, store_read_side_effect) return stores[path] with patch( diff --git a/tests/components/local_calendar/test_config_flow.py b/tests/components/local_calendar/test_config_flow.py index 2504932676218dd1fb3ed14d0eddd87b9731c02e..6cebd42cf3040f28fee663ef6a87537acc73a64f 100644 --- a/tests/components/local_calendar/test_config_flow.py +++ b/tests/components/local_calendar/test_config_flow.py @@ -2,10 +2,16 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.local_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.components.local_calendar.const import ( + CONF_CALENDAR_NAME, + CONF_STORAGE_KEY, + DOMAIN, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -31,5 +37,30 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["title"] == "My Calendar" assert result2["data"] == { CONF_CALENDAR_NAME: "My Calendar", + CONF_STORAGE_KEY: "my_calendar", } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_name( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test two calendars cannot be added with the same name.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + # Pick a name that has the same slugify value as an existing config entry + CONF_CALENDAR_NAME: "light schedule", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/local_calendar/test_init.py b/tests/components/local_calendar/test_init.py index e5ca209e8a667e8906b2520935763826fd729632..8e79cccea36d3aea6ec202706ce845b48d1e79c7 100644 --- a/tests/components/local_calendar/test_init.py +++ b/tests/components/local_calendar/test_init.py @@ -2,11 +2,36 @@ from unittest.mock import patch +import pytest + +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from .conftest import TEST_ENTITY + from tests.common import MockConfigEntry +async def test_load_unload( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test loading and unloading a config entry.""" + + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "off" + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "unavailable" + + async def test_remove_config_entry( hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry ) -> None: @@ -16,3 +41,20 @@ async def test_remove_config_entry( assert await hass.config_entries.async_remove(config_entry.entry_id) await hass.async_block_till_done() unlink_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("store_read_side_effect"), + [ + (OSError("read error")), + ], +) +async def test_load_failure( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test failures loading the store.""" + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + state = hass.states.get(TEST_ENTITY) + assert not state