Add Gatus Integration (#175085)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Hamish
2026-07-15 01:16:29 +09:30
committed by GitHub
parent 5b8c8578e4
commit 46f82fd3ca
22 changed files with 923 additions and 0 deletions
+1
View File
@@ -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.*
Generated
+2
View File
@@ -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
@@ -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)
@@ -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]
@@ -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."""
+3
View File
@@ -0,0 +1,3 @@
"""Constants for the Gatus integration."""
DOMAIN = "gatus"
@@ -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}
@@ -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"]
}
@@ -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
@@ -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"
}
}
}
+1
View File
@@ -264,6 +264,7 @@ FLOWS = {
"fyta",
"garages_amsterdam",
"gardena_bluetooth",
"gatus",
"gdacs",
"generic",
"geniushub",
@@ -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",
Generated
+10
View File
@@ -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
+3
View File
@@ -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
+15
View File
@@ -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()
+58
View File
@@ -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",
)
@@ -0,0 +1,8 @@
[
{
"key": "backend_service",
"name": "Backend Service",
"group": "Core",
"results": [{ "success": false, "status": 500 }]
}
]
@@ -0,0 +1,7 @@
[
{
"key": "backend_service",
"name": "Backend Service",
"results": [{ "success": true, "status": 200 }]
}
]
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'connectivity',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Core Backend Service',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.core_backend_service',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
@@ -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"
+154
View File
@@ -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"
+46
View File
@@ -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