diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 032bd2fd36d..557c3881629 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -37,7 +37,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.template import DATE_STR_FORMAT -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType @@ -308,19 +308,10 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for calendars.""" - component = hass.data[DATA_COMPONENT] = EntityComponent[CalendarEntity]( + component = hass.data[DATA_COMPONENT] = CalendarEntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) - hass.http.register_view(CalendarListView(component)) - hass.http.register_view(CalendarEventView(component)) - - frontend.async_register_built_in_panel(hass, "calendar", "calendar", "mdi:calendar") - - websocket_api.async_register_command(hass, handle_calendar_event_create) - websocket_api.async_register_command(hass, handle_calendar_event_delete) - websocket_api.async_register_command(hass, handle_calendar_event_update) - component.async_register_entity_service( CREATE_EVENT_SERVICE, CREATE_EVENT_SCHEMA, @@ -667,6 +658,57 @@ class CalendarEntity(Entity): raise NotImplementedError +class CalendarEntityComponent(EntityComponent[CalendarEntity]): + """Calendar entity component. + + Sets up frontend resources and websocket API when the first platform is added. + """ + + async def async_setup_entry(self, config_entry: ConfigEntry) -> bool: + """Set up a config entry.""" + num_platforms_before = len(self._platforms) + + result = await super().async_setup_entry(config_entry) + + # There will always be at least one platform, the calendar platform itself. + if num_platforms_before == 1 and len(self._platforms) == 2: + # First calendar platform was added, set up frontend resources + self._register_frontend_resources() + + return result + + async def async_setup_platform( + self, + platform_type: str, + platform_config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: + """Set up a platform for this component.""" + num_platforms_before = len(self._platforms) + + await super().async_setup_platform( + platform_type, platform_config, discovery_info + ) + + # There will always be at least one platform, the calendar platform itself. + if num_platforms_before == 1 and len(self._platforms) == 2: + # First calendar platform was added, set up frontend resources + self._register_frontend_resources() + + def _register_frontend_resources(self) -> None: + """Register frontend resources for calendar.""" + self.hass.http.register_view(CalendarListView(self)) + self.hass.http.register_view(CalendarEventView(self)) + + frontend.async_register_built_in_panel( + self.hass, "calendar", "calendar", "mdi:calendar" + ) + + websocket_api.async_register_command(self.hass, handle_calendar_event_create) + websocket_api.async_register_command(self.hass, handle_calendar_event_delete) + websocket_api.async_register_command(self.hass, handle_calendar_event_update) + + class CalendarEventView(http.HomeAssistantView): """View to retrieve calendar content.""" diff --git a/tests/components/calendar/conftest.py b/tests/components/calendar/conftest.py index 19d0d9b1d29..e52795b617f 100644 --- a/tests/components/calendar/conftest.py +++ b/tests/components/calendar/conftest.py @@ -17,7 +17,11 @@ from homeassistant.components.calendar import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from tests.common import ( @@ -125,12 +129,12 @@ async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture -def mock_setup_integration( +def mock_setup_config_entry_integration( hass: HomeAssistant, config_flow_fixture: None, test_entities: list[CalendarEntity], ) -> None: - """Fixture to set up a mock integration.""" + """Fixture to set up a mock integration with config entry.""" async def async_setup_entry_init( hass: HomeAssistant, config_entry: ConfigEntry @@ -178,6 +182,29 @@ def mock_setup_integration( ) +@pytest.fixture +def mock_setup_platform_integration( + hass: HomeAssistant, + config_flow_fixture: None, + test_entities: list[CalendarEntity], +) -> None: + """Fixture to set up a mock integration without config entry.""" + + async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: + pass + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_platform=async_setup_platform), + ) + + @pytest.fixture(name="test_entities") def mock_test_entities() -> list[MockCalendarEntity]: """Fixture that holdes the fake entities created during the test.""" diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 4945cddf9c9..c9286856e7b 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -8,6 +8,7 @@ from http import HTTPStatus import re from typing import Any +from aiohttp.test_utils import TestClient from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion @@ -20,10 +21,12 @@ from homeassistant.components.calendar import ( CalendarEntity, CalendarEntityDescription, ) +from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity, MockConfigEntry @@ -50,12 +53,23 @@ def mock_set_frozen_time(frozen_time: str | None) -> Generator[None]: yield -@pytest.fixture(name="setup_platform", autouse=True) -async def mock_setup_platform( +@pytest.fixture(name="setup_calendar_integration") +async def mock_setup_calendar_integration( hass: HomeAssistant, set_time_zone: None, frozen_time: str | None, - mock_setup_integration: None, +) -> None: + """Fixture to setup the calendar integration.""" + await async_setup_component(hass, DOMAIN, {}) + + +@pytest.fixture(name="setup_calendar_config_entry_platform") +# @pytest.fixture(name="setup_platform", autouse=True) +async def mock_setup_config_entry_platform( + hass: HomeAssistant, + set_time_zone: None, + frozen_time: str | None, + mock_setup_config_entry_integration: None, config_entry: MockConfigEntry, ) -> None: """Fixture to setup platforms used in the test and fixtures are set up in the right order.""" @@ -63,6 +77,7 @@ async def mock_setup_platform( await hass.async_block_till_done() +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") async def test_events_http_api( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: @@ -78,6 +93,7 @@ async def test_events_http_api( assert events[0]["summary"] == "Future Event" +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") async def test_events_http_api_missing_fields( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: @@ -87,6 +103,7 @@ async def test_events_http_api_missing_fields( assert response.status == HTTPStatus.BAD_REQUEST +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") async def test_events_http_api_error( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -106,6 +123,7 @@ async def test_events_http_api_error( assert await response.json() == {"message": "Error reading events: Failure"} +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") async def test_events_http_api_dates_wrong_order( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: @@ -119,6 +137,7 @@ async def test_events_http_api_dates_wrong_order( assert response.status == HTTPStatus.BAD_REQUEST +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") async def test_calendars_http_api( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: @@ -134,6 +153,7 @@ async def test_calendars_http_api( ] +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") @pytest.mark.parametrize( ("payload", "code"), [ @@ -222,6 +242,7 @@ async def test_unsupported_websocket( assert resp["error"].get("code") == code +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: """Test unsupported service call.""" with pytest.raises( @@ -242,6 +263,7 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("setup_calendar_integration") @pytest.mark.parametrize( ("date_fields", "expected_error", "error_match"), [ @@ -417,6 +439,7 @@ async def test_create_event_service_invalid_params( ) +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") @pytest.mark.parametrize( "frozen_time", ["2023-06-22 10:30:00+00:00"], ids=["frozen_time"] ) @@ -477,6 +500,7 @@ async def test_list_events_service( assert response == expected +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") @pytest.mark.parametrize( ("service"), [ @@ -516,6 +540,7 @@ async def test_list_events_service_duration( assert response == snapshot +@pytest.mark.usefixtures("setup_calendar_integration") async def test_list_events_positive_duration(hass: HomeAssistant) -> None: """Test listing events requires a positive duration.""" with pytest.raises(vol.Invalid, match="should be positive"): @@ -531,6 +556,7 @@ async def test_list_events_positive_duration(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("setup_calendar_integration") async def test_list_events_exclusive_fields(hass: HomeAssistant) -> None: """Test listing events specifying fields that are exclusive.""" end = dt_util.now() + timedelta(days=1) @@ -549,6 +575,7 @@ async def test_list_events_exclusive_fields(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("setup_calendar_integration") async def test_list_events_missing_fields(hass: HomeAssistant) -> None: """Test listing events missing some required fields.""" with pytest.raises(vol.Invalid, match="at least one of"): @@ -563,6 +590,7 @@ async def test_list_events_missing_fields(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("setup_calendar_integration") @pytest.mark.parametrize( "frozen_time", ["2023-06-22 10:30:00+00:00"], ids=["frozen_time"] ) @@ -613,6 +641,7 @@ async def test_list_events_service_same_dates( ) +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") async def test_calendar_initial_color_valid( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -628,6 +657,7 @@ async def test_calendar_initial_color_valid( assert entry.options.get(DOMAIN, {}).get("color") == "#FF0000" +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") @pytest.mark.parametrize( "invalid_initial_color", [ @@ -654,6 +684,7 @@ async def test_calendar_initial_color_invalid( assert entity.get_initial_entity_options() is None +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") async def test_calendar_initial_color_none( hass: HomeAssistant, test_entities: list[MockCalendarEntity], @@ -664,6 +695,7 @@ async def test_calendar_initial_color_none( assert entity.get_initial_entity_options() is None +@pytest.mark.usefixtures("setup_calendar_config_entry_platform") @pytest.mark.parametrize( ("description_color", "attr_color", "expected_color"), [ @@ -715,3 +747,77 @@ async def test_calendar_initial_color_precedence( entity = TestCalendarEntity(description_color, attr_color) assert entity.initial_color == expected_color + + +async def test_services_registered_after_integration_setup(hass: HomeAssistant) -> None: + """Test that services are registered after integration setup.""" + assert DOMAIN not in hass.services.async_services() + await async_setup_component(hass, DOMAIN, {}) + assert set(hass.services.async_services()[DOMAIN]) == {"create_event", "get_events"} + + +async def _assert_http_api_responses( + client: TestClient, + expected_status_calendar_list: HTTPStatus, + expected_status_calendar_event: HTTPStatus, +) -> None: + """Assert that the HTTP API endpoints return the expected status.""" + response = await client.get("/api/calendars") + assert response.status == expected_status_calendar_list + + response = await client.get("/api/calendars/calendar.calendar_1") + assert response.status == expected_status_calendar_event + + +async def test_frontend_resources_registered_after_first_config_entry_setup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + set_time_zone: None, + frozen_time: str | None, + mock_setup_config_entry_integration: None, + config_entry: MockConfigEntry, +) -> None: + """Test that frontend resources are registered after the first config entry is set up.""" + await async_setup_component(hass, "http", {}) + client = await hass_client() + await _assert_http_api_responses(client, HTTPStatus.NOT_FOUND, HTTPStatus.NOT_FOUND) + assert "frontend_panels" not in hass.data + assert "websocket_api" not in hass.data + + await async_setup_component(hass, DOMAIN, {}) + await _assert_http_api_responses(client, HTTPStatus.NOT_FOUND, HTTPStatus.NOT_FOUND) + assert "frontend_panels" not in hass.data + assert "websocket_api" not in hass.data + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await _assert_http_api_responses(client, HTTPStatus.OK, HTTPStatus.BAD_REQUEST) + assert set(hass.data["frontend_panels"]) == {"calendar"} + assert set(hass.data["websocket_api"]) == { + "calendar/event/create", + "calendar/event/delete", + "calendar/event/update", + } + + +async def test_frontend_resources_registered_after_first_platform_setup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + set_time_zone: None, + frozen_time: str | None, + mock_setup_platform_integration: None, +) -> None: + """Test that frontend resources are registered after the first platform is set up.""" + await async_setup_component(hass, "http", {}) + client = await hass_client() + await _assert_http_api_responses(client, HTTPStatus.NOT_FOUND, HTTPStatus.NOT_FOUND) + assert "frontend_panels" not in hass.data + assert "websocket_api" not in hass.data + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await _assert_http_api_responses(client, HTTPStatus.OK, HTTPStatus.BAD_REQUEST) + assert set(hass.data["frontend_panels"]) == {"calendar"} + assert set(hass.data["websocket_api"]) == { + "calendar/event/create", + "calendar/event/delete", + "calendar/event/update", + } diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index c7511b8b2b0..715f87c28d1 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -19,7 +19,7 @@ async def mock_setup_dependencies( recorder_mock: Recorder, hass: HomeAssistant, set_time_zone: None, - mock_setup_integration: None, + mock_setup_config_entry_integration: None, config_entry: MockConfigEntry, ) -> None: """Fixture that ensures the recorder is setup in the right order.""" diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 8435f43f930..924cc3355d7 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -279,7 +279,7 @@ def mock_test_entity(test_entities: list[MockCalendarEntity]) -> MockCalendarEnt @pytest.fixture(name="setup_platform", autouse=True) async def mock_setup_platform( hass: HomeAssistant, - mock_setup_integration: None, + mock_setup_config_entry_integration: None, config_entry: MockConfigEntry, ) -> None: """Fixture to setup platforms used in the test."""