diff --git a/.strict-typing b/.strict-typing index e3629e702389..400f8d1f32ed 100644 --- a/.strict-typing +++ b/.strict-typing @@ -228,6 +228,7 @@ homeassistant.components.fujitsu_fglair.* homeassistant.components.fully_kiosk.* homeassistant.components.fumis.* homeassistant.components.fyta.* +homeassistant.components.gatus.* homeassistant.components.generic_hygrostat.* homeassistant.components.generic_thermostat.* homeassistant.components.geo_location.* diff --git a/CODEOWNERS b/CODEOWNERS index 8d02a119003d..ccb837bedb74 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -625,6 +625,8 @@ CLAUDE.md @home-assistant/core /tests/components/gardena_bluetooth/ @elupus /homeassistant/components/gate/ @home-assistant/core /tests/components/gate/ @home-assistant/core +/homeassistant/components/gatus/ @TN-1 +/tests/components/gatus/ @TN-1 /homeassistant/components/gdacs/ @exxamalte /tests/components/gdacs/ @exxamalte /homeassistant/components/generic/ @davet2001 diff --git a/homeassistant/components/gatus/__init__.py b/homeassistant/components/gatus/__init__.py new file mode 100644 index 000000000000..93cbcfc5999a --- /dev/null +++ b/homeassistant/components/gatus/__init__.py @@ -0,0 +1,25 @@ +"""The Gatus integration.""" + +from homeassistant.const import CONF_URL, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool: + """Set up Gatus from a config entry.""" + coordinator = GatusDataUpdateCoordinator(hass, entry, entry.data[CONF_URL]) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/gatus/binary_sensor.py b/homeassistant/components/gatus/binary_sensor.py new file mode 100644 index 000000000000..f35d8815e42d --- /dev/null +++ b/homeassistant/components/gatus/binary_sensor.py @@ -0,0 +1,105 @@ +"""Support for Gatus binary sensors.""" + +from typing import override + +from gatus_api import EndpointStatus, Result + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GatusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Gatus binary sensor platform.""" + coordinator = entry.runtime_data + + async_add_entities( + GatusEndpointBinarySensor(coordinator, entry, endpoint_key) + for endpoint_key in coordinator.data + ) + + +class GatusEndpointBinarySensor( + CoordinatorEntity[GatusDataUpdateCoordinator], BinarySensorEntity +): + """Representation of a Gatus endpoint status.""" + + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: GatusDataUpdateCoordinator, + entry: GatusConfigEntry, + endpoint_key: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._endpoint_key = endpoint_key + + endpoint_data = self.endpoint_data + + endpoint_name = endpoint_data.name + if endpoint_data.group is not None: + device_name = f"{endpoint_data.group} {endpoint_name}" + else: + device_name = endpoint_name + + self._attr_unique_id = f"{entry.entry_id}_{endpoint_key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.entry_id}_{endpoint_key}")}, + name=device_name, + manufacturer="Gatus", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + @override + def is_on(self) -> bool | None: + """Return true if the endpoint is up and healthy.""" + latest_result = self.latest_result + if latest_result is None: + return None + + return latest_result.success + + @property + @override + def available(self) -> bool: + """Return True if entity is available.""" + data = self.coordinator.data + # Guard for empty results list, which could imply a brand new endpoint + return ( + super().available + and self._endpoint_key in data + and bool(data[self._endpoint_key].results) + ) + + @property + def endpoint_data(self) -> EndpointStatus: + """Return this specific endpoint's data from the coordinator.""" + return self.coordinator.data[self._endpoint_key] + + @property + def latest_result(self) -> Result | None: + """Return the most recent monitoring result (Gatus appends newest last).""" + results = self.endpoint_data.results + if not results: + return None + return results[-1] diff --git a/homeassistant/components/gatus/config_flow.py b/homeassistant/components/gatus/config_flow.py new file mode 100644 index 000000000000..972f200abae7 --- /dev/null +++ b/homeassistant/components/gatus/config_flow.py @@ -0,0 +1,87 @@ +"""Config flow for the Gatus integration.""" + +import logging +from typing import Any, override + +from gatus_api import GatusClient, GatusClientError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate that the user input allows us to connect to Gatus and return data.""" + client = GatusClient(url=data[CONF_URL], session=async_get_clientsession(hass)) + + try: + await client.get_endpoints_statuses() + except GatusClientError as err: + _LOGGER.debug("Cannot connect to Gatus instance at %s: %s", data[CONF_URL], err) + raise CannotConnect from err + + +class GatusConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Gatus.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial setup step when adding the integration via the UI.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + url = URL(user_input[CONF_URL]) + except ValueError: + errors["base"] = "invalid_url" + else: + if url.scheme not in {"http", "https"} or not url.host: + errors["base"] = "invalid_url" + else: + normalized_url = str( + url.with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + user_input[CONF_URL] = normalized_url + + self._async_abort_entries_match({CONF_URL: normalized_url}) + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Gatus setup") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Gatus", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect to the server.""" diff --git a/homeassistant/components/gatus/const.py b/homeassistant/components/gatus/const.py new file mode 100644 index 000000000000..89ac9ee41fff --- /dev/null +++ b/homeassistant/components/gatus/const.py @@ -0,0 +1,3 @@ +"""Constants for the Gatus integration.""" + +DOMAIN = "gatus" diff --git a/homeassistant/components/gatus/coordinator.py b/homeassistant/components/gatus/coordinator.py new file mode 100644 index 000000000000..37739f2ff6f3 --- /dev/null +++ b/homeassistant/components/gatus/coordinator.py @@ -0,0 +1,48 @@ +"""DataUpdateCoordinator for the Gatus integration.""" + +from datetime import timedelta +import logging +from typing import override + +from gatus_api import EndpointStatus, GatusClient, GatusClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type GatusConfigEntry = ConfigEntry[GatusDataUpdateCoordinator] + + +class GatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, EndpointStatus]]): + """Class to manage fetching Gatus data from the API via third-party library.""" + + def __init__(self, hass: HomeAssistant, entry: GatusConfigEntry, url: str) -> None: + """Initialize the coordinator.""" + self.url = url.rstrip("/") + self.client = GatusClient(url=self.url, session=async_get_clientsession(hass)) + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + @override + async def _async_update_data(self) -> dict[str, EndpointStatus]: + """Fetch endpoint statuses from the Gatus API.""" + try: + raw_endpoints = await self.client.get_endpoints_statuses() + except GatusClientError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from err + + return {ep.key: ep for ep in raw_endpoints} diff --git a/homeassistant/components/gatus/manifest.json b/homeassistant/components/gatus/manifest.json new file mode 100644 index 000000000000..53fddeab56ed --- /dev/null +++ b/homeassistant/components/gatus/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "gatus", + "name": "Gatus", + "codeowners": ["@TN-1"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gatus", + "integration_type": "service", + "iot_class": "local_polling", + "loggers": ["gatus_api"], + "quality_scale": "silver", + "requirements": ["gatus-api==1.0.3"] +} diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml new file mode 100644 index 000000000000..3d9207ece6b9 --- /dev/null +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + entity-event-setup: + status: exempt + comment: Integration does not register custom events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration does not use authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration does not support discovery. + discovery: + status: exempt + comment: Integration does not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: All entities represent monitored services and should be enabled by default. + entity-translations: + status: exempt + comment: Entity names are dynamically provided by the Gatus service. + exception-translations: done + icon-translations: + status: exempt + comment: Entities use the connectivity device class for their icon and define no custom icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not require user intervention repairs. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/gatus/strings.json b/homeassistant/components/gatus/strings.json new file mode 100644 index 000000000000..6f6610ddbb01 --- /dev/null +++ b/homeassistant/components/gatus/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_url": "Please enter a valid absolute URL (e.g., http://192.168.1.50:8080)", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The full base URL of your Gatus status page instance including protocol and port." + }, + "description": "Enter the network details for your Gatus status page instance. Make sure to include the protocol (e.g., `http://` or `https://`) and the port number if you are not using a standard port." + } + } + }, + "exceptions": { + "update_failed": { + "message": "Error communicating with Gatus API" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 83f559cf2811..da9a9ef06b1b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -264,6 +264,7 @@ FLOWS = { "fyta", "garages_amsterdam", "gardena_bluetooth", + "gatus", "gdacs", "generic", "geniushub", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2387a9de906f..b676f78a5c2a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2388,6 +2388,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "gatus": { + "name": "Gatus", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "gaviota": { "name": "Gaviota", "integration_type": "virtual", diff --git a/mypy.ini b/mypy.ini index 73645a4a2360..2da3ccca92c7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2037,6 +2037,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.gatus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.generic_hygrostat.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index aa4cf64c0ab9..ab54a315cbee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1077,6 +1077,9 @@ gardena-bluetooth==2.8.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.14 +# homeassistant.components.gatus +gatus-api==1.0.3 + # homeassistant.components.google gcal-sync==8.0.0 diff --git a/tests/components/gatus/__init__.py b/tests/components/gatus/__init__.py new file mode 100644 index 000000000000..26e3d9d4d9b1 --- /dev/null +++ b/tests/components/gatus/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Gatus integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Gatus integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/gatus/conftest.py b/tests/components/gatus/conftest.py new file mode 100644 index 000000000000..1e557575e259 --- /dev/null +++ b/tests/components/gatus/conftest.py @@ -0,0 +1,58 @@ +"""Common fixtures for the Gatus tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from gatus_api import EndpointStatus, Result +import pytest + +from homeassistant.components.gatus.const import DOMAIN +from homeassistant.const import CONF_URL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.gatus.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_gatus_client() -> Generator[AsyncMock]: + """Mock the third-party Gatus API client wrapper globally across coordinator and config flow.""" + with ( + patch( + "homeassistant.components.gatus.coordinator.GatusClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.gatus.config_flow.GatusClient", + new=mock_client, + ), + ): + client_instance = mock_client.return_value + client_instance.get_endpoints_statuses = AsyncMock( + return_value=[ + EndpointStatus( + key="backend_service", + name="Backend Service", + group="Core", + results=[Result(success=True, status=200)], + ) + ] + ) + yield client_instance + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Fixture to cleanly create a Gatus configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://gatus.example.com:8080"}, + entry_id="1234567890abcdef1234567890abcdef", + ) diff --git a/tests/components/gatus/fixtures/group.json b/tests/components/gatus/fixtures/group.json new file mode 100644 index 000000000000..8c7c032441e2 --- /dev/null +++ b/tests/components/gatus/fixtures/group.json @@ -0,0 +1,8 @@ +[ + { + "key": "backend_service", + "name": "Backend Service", + "group": "Core", + "results": [{ "success": false, "status": 500 }] + } +] diff --git a/tests/components/gatus/fixtures/no_group.json b/tests/components/gatus/fixtures/no_group.json new file mode 100644 index 000000000000..c582a4eb7535 --- /dev/null +++ b/tests/components/gatus/fixtures/no_group.json @@ -0,0 +1,7 @@ +[ + { + "key": "backend_service", + "name": "Backend Service", + "results": [{ "success": true, "status": 200 }] + } +] diff --git a/tests/components/gatus/snapshots/test_binary_sensor.ambr b/tests/components/gatus/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..56d2fc37d30b --- /dev/null +++ b/tests/components/gatus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_binary_sensor_setup_and_states[binary_sensor.core_backend_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.core_backend_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'gatus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890abcdef1234567890abcdef_backend_service', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_setup_and_states[binary_sensor.core_backend_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'Core Backend Service', + }), + 'context': , + 'entity_id': 'binary_sensor.core_backend_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/gatus/test_binary_sensor.py b/tests/components/gatus/test_binary_sensor.py new file mode 100644 index 000000000000..761a2c757a0f --- /dev/null +++ b/tests/components/gatus/test_binary_sensor.py @@ -0,0 +1,164 @@ +"""Tests for the Gatus binary sensor platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from gatus_api import EndpointStatus, GatusClientError, Result +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_array_fixture, + snapshot_platform, +) + + +async def test_binary_sensor_setup_and_states( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test standard successful setup and entity snapshots using snapshot_platform.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +def _to_endpoint_statuses(raw_data: list[dict[str, Any]]) -> list[EndpointStatus]: + return [ + EndpointStatus( + key=ep["key"], + name=ep["name"], + group=ep.get("group"), + results=[ + Result(success=r["success"], status=r["status"]) + for r in ep.get("results", []) + ], + ) + for ep in raw_data + ] + + +async def test_binary_sensor_dynamic_update( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the binary sensor entity updates when the mock client returns new data.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("binary_sensor.core_backend_service") + assert state is not None + assert state.state == "on" + + mock_data = await async_load_json_array_fixture(hass, "gatus/group.json") + + mock_gatus_client.get_endpoints_statuses.return_value = _to_endpoint_statuses( + mock_data + ) + + freezer.tick(300) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.core_backend_service") + assert state.state == "off" + + +async def test_binary_sensor_no_group( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the binary sensor entity is created correctly when an endpoint has no group.""" + mock_data = await async_load_json_array_fixture(hass, "gatus/no_group.json") + + mock_gatus_client.get_endpoints_statuses.return_value = _to_endpoint_statuses( + mock_data + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "on" + + +async def test_binary_sensor_client_error( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a client exception cleanly marks entities as unavailable.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("binary_sensor.core_backend_service") + assert state is not None + assert state.state == "on" + + mock_gatus_client.get_endpoints_statuses.side_effect = GatusClientError + + freezer.tick(30) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.core_backend_service") + assert state.state == "unavailable" + + +async def test_binary_sensor_empty_results( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an endpoint with empty results is treated as unavailable.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "unavailable" + + # Verify underlying properties return None directly on empty results + entity = hass.data["binary_sensor"].get_entity("binary_sensor.backend_service") + assert entity is not None + assert entity.latest_result is None + assert entity.is_on is None + + +async def test_binary_sensor_missing_status( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an endpoint with a result missing a status code is handled correctly.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[Result(success=False, status=None)], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "off" diff --git a/tests/components/gatus/test_config_flow.py b/tests/components/gatus/test_config_flow.py new file mode 100644 index 000000000000..45fbc09afe8c --- /dev/null +++ b/tests/components/gatus/test_config_flow.py @@ -0,0 +1,154 @@ +"""Test the Gatus Config flow.""" + +from unittest.mock import AsyncMock + +from gatus_api import GatusClientError +import pytest + +from homeassistant import config_entries +from homeassistant.components.gatus.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_form_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form, validate the client, and create a successful entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gatus" + assert result["data"] == { + CONF_URL: "http://gatus.example.com:8080", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_form_success_with_path( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form, validate the client, and create a successful entry with a sub-path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080/gatus-instance/"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gatus" + assert result["data"] == { + CONF_URL: "http://gatus.example.com:8080/gatus-instance", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_gatus_client: AsyncMock +) -> None: + """Test handling of a malformed URL and subsequent recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "gatus.example.com"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:abc"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (GatusClientError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected backend explosion"), "unknown"), + ], +) +async def test_form_failures_and_recovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_gatus_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test handling validation failures and ensuring the flow can completely recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_gatus_client.get_endpoints_statuses.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_gatus_client.get_endpoints_statuses.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that duplicate configurations for the same base URL abort early.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/gatus/test_init.py b/tests/components/gatus/test_init.py new file mode 100644 index 000000000000..ebb0f2d772c3 --- /dev/null +++ b/tests/components/gatus/test_init.py @@ -0,0 +1,46 @@ +"""Tests for the Gatus integration setup and unload lifecycle.""" + +from unittest.mock import AsyncMock + +from gatus_api import GatusClientError +import pytest + +from homeassistant.components.gatus.coordinator import GatusDataUpdateCoordinator +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_setup_and_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test standard successful setup and unload cycle of the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.runtime_data is not None + assert isinstance(mock_config_entry.runtime_data, GatusDataUpdateCoordinator) + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_failure_retry( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an API connection failure during initial setup places the entry in retry state.""" + mock_gatus_client.get_endpoints_statuses.side_effect = GatusClientError( + "Cannot connect to Gatus API during initial setup" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY